一口气说出Kafka为啥这么快?
在过去的几年里,软件架构领域发生了巨大的变化。人们不再认为所有的系统都应该共享一个数据库。
图片来自 Pexels
微服务、事件驱动架构和 CQRS(命令查询的责任分离 Command Query Responsibility Segregation)是构建当代业务应用程序的主要工具。
除此以外,物联网、移动设备和可穿戴设备的普及,进一步对系统的近实时能力提出了挑战。
首先让我们对“快”这个词达成共识,这个词是多方面的、复杂的、高度模糊的。一种解释是把”延迟、吞吐量和抖动“作为对“快”的衡量指标。
还有,比如工业应用领域,行业本身设置了对于“快”的规范和期望。所以,“快”在很大程度上取决于你的参照体系是什么。
Apache Kafka 以牺牲延迟和抖动为代价优化了吞吐量,但并没有牺牲,比如持久性、严格的记录有序性和至少一次的分发语义。
当有人说“Kafka 速度很快”,并假设他们至少有一定的能力时,你可以认为他们指的是 Kafka 在短时间内分发大量记录的能力。
Kafka 诞生于 LinkedIn,当时 LinkedIn 需要高效地传递大量信息,相当于每小时传输数 TB 的数据量。
在当时,消息传播的延迟被认为是可以接受的。毕竟,LinkedIn 不是一家从事高频交易的金融机构,也不是一个在确定期限内运行的工业控制系统。Kafka 可用于近实时系统。
注意:“实时”并不意味着“快”,它的意思是“可预测的”。具体来说,实时意味着完成一个动作具有时间限制,也就是最后期限。
如果一个系统不能满足这个要求,它就不能被归类为”实时系统“。能够容忍一定范围内延迟的系统被称为“近实时”系统。从吞吐量的角度来说,实时系统通常比近实时或非实时系统要慢。
Kafka 在速度上有两个重要的方面,需要单独讨论:
与客户端与服务端之间的低效率实现有关。
源自于流处理的并行性。
服务端优化
日志的存储
Kafka 利用分段、追加日志的方式,在很大程度上将读写限制为顺序 I/O(sequential I/O),这在大多数的存储介质上都很快。人们普遍错误地认为硬盘很慢。
然而,存储介质的性能,很大程度上依赖于数据被访问的模式。同样在一块普通的 7200 RPM SATA 硬盘上,随机 I/O(random I/O)与顺序 I/O 相比,随机 I/O 的性能要比顺序 I/O 慢 3 到 4 个数量级。
此外,现代的操作系统提供了预先读和延迟写的技术,这些技术可以以块为单位,预先读取大量数据,并将较小的逻辑写操作合并成较大的物理写操作。
因此,顺序 I/O 和随机 I/O 之间的性能差异在闪存和其他固态非易失性介质中仍然很明显,不过它们在旋转存储,比如固态硬盘中的性能差异就没有那么明显。
记录的批处理
顺序 I/O 在大多数存储介质上都非常快,可以与网络 I/O 的最高性能相媲美。在实践中,这意味着一个设计良好的日志持久化层能跟上网络的读写速度。事实上,Kafka 的性能瓶颈通常并不在硬盘上,而是网络。
因此,除了操作系统提供的批处理外,Kafka 的客户端和服务端会在一个批处理中积累多个记录——包括读写记录,然后在通过网络发送出去。
记录的批处理可以缓解网络往返的开销,使用更大的数据包,提高带宽的效率。
批量压缩
当启用压缩时,对批处理的影响特别明显,因为随着数据大小的增加,压缩通常会变得更有效。
特别是在使用基于文本的格式时,比如 JSON,压缩的效果会非常明显,压缩比通常在 5x 到 7x 之间。
此外,记录的批处理主要作为一个客户端操作,负载在传递的过程中,不仅对网络带宽有积极影响,而且对服务端的磁盘 I/O 利用率也有积极影响。
便宜的消费者
不同于传统的消息队列模型,当消息被消费时会删除消息(会导致随机 I/O),Kafka 不会在消息被消费后删除它们——相反,它会独立地跟踪每个消费者组的偏移量。
可以参考 Kafka 的内部主题 __consumer_offsets 了解更多。同样,由于只是追加操作,所以速度很快。消息的大小在后台被进一步减少(使用 Kafka 的压缩特性),只保留任何给定消费者组的最后已知偏移量。
将此模型与传统的消息模型进行对比,后者通常提供几种不同的消息分发拓扑。
一种是消息队列——用于点对点消息传递的持久化传输,没有点对多点功能。
另一种是发布订阅主题允许点对多点消息通信,但这样做的代价是持久性。在传统消息队列模型中实现持久化的点对多点消息通信模型需要为每个有状态的使用者维护专用消息队列。
这将放大读写的消耗。消息生产者被迫将消息写入多个消息队列中。另外一种选择是使用扇出中继,扇出中继可以消费来自一个队列中的记录,并将记录写入其他多个队列中,但这只会将延迟放大点。
并且,一些消费者正在服务端上生成负载——读和写 I/O 的混合,既有顺序的,也有随机的。
Kafka 中的消费者是“便宜的”,只要他们不改变日志文件(只有生产者或 Kafka 的内部进程被允许这样做)。
这意味着大量消费者可以并发地从同一主题读取数据,而不会使集群崩溃。添加一个消费者仍然有一些成本,但主要是顺序读取夹杂很少的顺序写入。
因此,在一个多样化的消费者系统中,看到一个主题被共享是相当正常的。
未刷新的缓冲写操作
Kafka 性能的另一个基本原因是,一个值得进一步研究的原因:Kafka 在确认写操作之前并没有调用 fsync。ACK 的唯一要求是记录已经写入 I/O 缓冲区。
这是一个鲜为人知的事实,但却是一个至关重要的事实。实际上,这就是 Kafka 的执行方式,就好像它是一个内存队列一样——Kafka 实际上是一个由磁盘支持的内存队列(受缓冲区/页面缓存大小的限制)。
但是,这种形式的写入是不安全的,因为副本的出错可能导致数据丢失,即使记录似乎已经被 ACK。
换句话说,与关系型数据库不同,仅写入缓冲区并不意味着持久性。保证 Kafka 持久性的是运行几个同步的副本。
即使其中一个出错了,其他的(假设不止一个)将继续运行——假设出错的原因不会导致其他的副本也出错。
因此,无 fsync 的非阻塞 I/O 方法和冗余的同步副本组合为 Kafka 提供了高吞吐、持久性和可用性。
客户端优化
大多数数据库、队列和其他形式的持久性中间件都是围绕全能服务器(或服务器集群)和瘦客户端的概念设计的。
客户端的实现通常被认为比服务器端简单得多。服务器会处理大部分的负载,而客户端仅充当服务端的门面。
Kafka 采用了不同的客户端设计方法。在记录到达服务器之前,会在客户端上执行大量的工作。
这包括对累加器中的记录进行分段、对记录键进行散列以得到正确的分区索引、对记录进行校验以及对记录批处理进行压缩。
客户端知道集群元数据,并定期刷新元数据以跟上服务端拓扑的更改。这让客户端更准确的做出转发决策。
不同于盲目地将记录发送到集群并依靠后者将其转发到适当的节点,生产者客户端可以直接将写请求转发到分区主机。
类似地,消费者客户端能够在获取记录时做出更明智的决定,比如在发出读查询时,可以使用在地理上更接近消费者客户端的副本。(该特性是从 Kafka 的 2.4.0 版本开始提供。)
零拷贝
一种典型的低效方式是在缓冲之间复制字节数据。Kafka 使用由生产者、消费者、服务端三方共享的二进制消息格式,这样即使数据块被压缩了,也可以不加修改地传递数据。
虽然消除通信方之间的数据结构差异是重要的一步,但它本身并不能避免数据的复制。
Kafka 使用 Java 的 NIO 框架,特别是 java.nio.channels.FileChannel 的 transferTo() 方法,在 Linux 和 UNIX 系统上解决了这个问题。
此方法允许字节从源通道传输到接收通道,而不需要将应用程序作为传输中介。
了解 NIO 的不同之处,请思考传统的方法会怎么做,将源通道读入字节缓冲区,然后作为两个独立的操作写入接收器通道:
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
可以用下图来表示:
下图概述了每次步骤的上下文切换:
初始的 read() 方法导致上下文从用户态切换到内核态。文件被读取,它的内容被 DMA(Direct Memory Access 直接存储器访问)引擎复制到内核地址空间中的缓冲区。这与代码段中使用的缓冲区是不同的。
在 read() 方法返回之前,将数据从内核缓冲区复制到用户空间缓冲区。此时,我们的应用程序可以读取文件的内容了。
随后的 send() 方法将切回到内核态,将数据从用户空间缓冲区复制到内核地址空间——这一次是将数据复制到与目标套接字相关联的另一个缓冲区中。在后台,由 DMA 引擎接管,异步地将数据从内核缓冲区复制到协议栈。send() 方法在返回之前不会等待这个操作完成。
send() 方法调用返回,切回用户态。
相比之下,零拷贝方法是在单个操作中处理的。前面例子中的代码可以改写为一行代码:
fileDesc.transferTo(offset, len, socket);
下面详细解释说明是零拷贝:
然后,将数据从读缓冲区复制到套接字缓冲区。最后,通过 DMA 将数据从套接字缓冲区复制到 NIC 缓冲区。
如下图所示:
避免垃圾回收
流处理的并行性
①主题分区方案。应该对主题进行分区,最大化事件流的数量。换句话说,只有在绝对需要时才提供记录的顺序。
如果任何两个记录不存在关联,它们就不应该被绑定到同一个分区。这意味着要使用不同的键,因为 Kafka 使用记录键的散列值作为分区映射的根据。
②组中消费者的数量。你可以增加消费者的数量来均衡入站记录的负载,消费者的数量最多可以增加到和分区数量一样多。(你可以增加更多的消费者,但每个分区最多只能有一个的活动消费者,剩下的消费者将处于闲置状态)
请注意,你可以提供一个线程池,根据消费者执行工作负载的不同,消费者可以是一个进程或一个线程。
相关链接:
https://pulsar.apache.org/
https://medium.com/swlh/introduction-to-event-streaming-with-kafka-and-kafdrop-22afdb4b380a
作者:钟涛编译
编辑:陶家龙
出处:转载自微信公众号分布式实验室(ID:dockerone)
原文:https://medium.com/swlh/why-kafka-is-so-fast-bde0d987cd03
精彩文章推荐:
百万年薪挖了个P7程序员,难道是“水货”?喜极而泣,我终于学会了Nginx!
扛住100亿次红包请求的架构是这样设计的!